本文为学习UnityShader时所记录的笔记,供学习产出和日后复习使用。
学习资料为冯乐乐(问就是我女神)的《UnityShader入门精要》
基础shader编写
我们正式开始使用unityshader来编写最简单的顶点/片元着色器
着色器基本结构
顶点着色器/片元着色器的基本结构大体相同
包含了Shader,Properties,SubShader,Fallback等语义块。
Shader "MyShaderName"
{
Properties
{
//属性
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert//用于指定顶点着色器的名字,这里是vert
#pragma fragment frag//用于指定片元着色器的名字,这里是frag
//以下是Cg代码
float4 vert(float4 v:POSITION) {
return mul(UNITY_MATRIX_MVP,v);
}
fixed4 frag() : SV_Target{
return fixed4(1.0,1.0,1.0,1.0);
}
ENDCG
}
}
//Fallback "XXXXX" //上述SubShader渲染失败后回调此Shader
}
在我们的例子中,我们编写了顶点着色器和片元着色器(一般也就写这两个)。
先看看顶点着色器,float4是函数的返回值,vert的函数名,float4和v是参数类型和形参(和我们的编程语言还蛮像的)
这里的POSITION是Cg中的语义,是不可省略的,用于告诉系统用户输入的值是什么类型的,以及输出什么样的值。
我的理解是就和float一样用来表示数据类型的,相当于是在float的前提下再做了一个修饰。
我们都记得顶点着色器的任务是将模型空间的坐标转化到剪裁空间。
这里的POSITION就表示输入的float4数据是应用阶段输出的模型顶点坐标,如果没有POSITION我们就不知道这个float4的数据有甚么意义。
PS:
dx10中引入了SV_POSITION的系统数值语义,它和POSITION的等阶的
但在某些平台(索尼ps4)上必须使用SV_POSITION来修饰顶点着色器的输出,否则Shader无法正常工作。
为了让我们的Shader具有更好的跨平台性,建议使用SV开头的语义进行修饰
接下来把目光转移到片元着色器上。在我们的例子中它没有任何输入(具体情况肯定不这么写啦),输出一个Fixed4类型的值,并使用了SV_Target进行限定。
片元着色器的目标是输出一个颜色值,这个SV_Target表示将fixed4的这个数据输出到帧缓存(没听懂?那就记住都要加SV_Target就完事了)
片元着色器输出的颜色每个分量都在[0,1]之间,其中(0,0,0)表示黑色,而(1,1,1)表示白色。
按上述代码编写,我们大概会得到一个纯白的材质效果。
更多的模型数据
虽然顶点着色器最基本的任务是将模型顶点转换到剪裁空间,但我们希望获取的顶点信息肯定不仅限它的坐标位置。
我们同样需要纹理坐标,法线,切线,顶点颜色等信息(这个需求是非常常见的),该怎么做呢?答案是使用结构体!
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
struct a2v {
float4 vertex:POSITION;//告诉unity用顶点坐标填充vertex向量
float3 normal:NORMAL;//告诉unity用法线方向填充normal
float4 texcoord:TEXCOORD;//告诉unity用模型的第一套纹理坐标填充texcoord变量
};
float4 vert(a2v v) {
return mul(UNITY_MATRIX_MVP,v.vertex);
}
fixed4 frag() : SV_Target{
...
}
ENDCG
}
}
有过编程经验的大家对结构体一定都很熟悉,这里就不说明结构体的声明和使用语法了,照葫芦画瓢就行,但记住这里也要加语义限定哦。
a2v是什么意思?指的是application to vertex,也就是将数据从应用阶段传递到顶点着色器
顶点着色器和片元着色器的通信
在很多情况下,片元着色器的输入都是顶点着色器计算得出的结果,因此就涉及到两个着色器的通信
其实很简单,我们只需要再建一个结构体就行(有编程经验的学shader代码理解得真的很快对吧)
Pass
{
CGPROGRAM
#include "UnityCG.cginc"
#pragma vertex vert
#pragma fragment frag
fixed4 _Color;
struct a2v {
float4 vertex:POSITION;//告诉unity用顶点坐标填充vertex向量
float3 normal:NORMAL;//告诉unity用法线方向填充normal
float4 texcoord:TEXCOORD;//告诉unity用模型的第一套纹理坐标填充texcoord变量
};
struct v2f {
float4 pos:SV_POSITION;
fixed3 color:COLOR0;//告诉unity储存颜色
};
v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.color = v.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
return o;
}
fixed4 frag(v2f i) : SV_Target{
fixed3 c = i.color;
c *= _Color.rgb;
return fixed4(c,1.0);
}
ENDCG
}
}
在上面的代码中,我们定义了一个新的结构体v2f(vertex to fragment不用我多说了吧),同样里面每个变量也要指定语义。
得注意的是,片元着色器的输出结构中必须包含一个语义为SV_POSITION,否则渲染器就无法得到裁剪空间的坐标,更无法成功将顶点渲染。
在本例中,顶点着色器接收a2v的数据,计算出一个v2f(位置,颜色)的值为片元着色器所用,片元着色器直接把v2f的颜色信息输出了。
这样就实现了两个着色器的通信,在今后的案例也是如此的,应该很好理解。
属性的使用
假如我们想要直接在材质面板控制屏幕上显示的颜色,记得我们说过的吗?在Properties内声明属性就能将其在材质面板显示。
Properties{
_Color("Color Tint",Color) = (1.0,1.0,1.0,1.0)
}
属性编写的具体格式在第二章节的笔记有详细说明,包括常见属性和其类型。
UNITY的内置文件和变量
包含文件(include file)是类似于C++中头文件的一种文件,后缀为.cginc。
我们可以使用#include的将其包含进来,这样就能使用Unity为我们提供的特别有用的变量和帮助函数。
![image-20210917225515044]image-20210917225515044.png)
其中“UnityCG.cginc”是我们最常接触的包含文件。
emmm今天一整天都在摸鱼呢 ———-2021.9.17 23:00完成
Unity中的基础光照
渲染总是围绕着一个基础问题:我们如何决定一个像素的颜色?
宏观的来说,这包括一个像素的可见性和光照计算。
而光照模型就为我们进行光照计算提供了帮助。
光照概念总述
辐照度(irradiance):垂直于单位面积上单位时间内穿过的能量,是用来量化光的单位。辐照度和cosθ成正比(光源l和法线n的点积)
散射(scattering):只改变光的方向,不改变光的密度和颜色。散射到物体内部的称为折射(refraction),散射到外部的称为反射(reflection)
在光照模型中,我们用高光反射(specular)来计算物体表面如何反射光线
使用漫反射(diffuse)来计算多少光线被折射,吸收,散射出表面。
根据出射光线的数量和方向还能计算出出射度(和辐照度满足线性关系)
ps:漫反射和高光反射的概念如果不能理解上面,个人感觉按初中物理那样理解的也可以
着色(shading)是指根据材质属性(漫反射属性,光源信息等),使用一个公式流程去计算得出沿某个方向的出射度。
而这个公式就被称为光照模型(lighting model)。
光照模型并不能反应物体和光照之间的真实交互,但它们都是对真实场景理想化和简化后的模型,能拥有较为理想的效果
这些光照模型我们称为经验模型。
计算机图形学第一定律:如果它看起来是对的,那么它就是对的。
标准光照模型(定义和公式)
我们将进入到摄像机的光线分为四个部分,每部分使用一种方法来计算它的贡献度。
自发光(emissive):用于描述给定一个方向时,物体表面本身会朝该方向发射多少光(辐射量)。
若没有开启全局光照,自发光并不会真的照亮周围物体,只是让它本身看起来更亮了。
高光反射(specular):用于描述光线从光线照射到表面时,会在完全镜面方向反射多少辐射量。
漫反射(diffuse):用于描述光线从光线照射到表面时,会在完全镜面方向散射多少辐射量。
环境光(ambient):用于描述其他所有间接光照。
自发光计算
直接使用该材质的自发光颜色。
在此书的学习中,unityshader的编写大部分没有计算这方面。
若要计算也很简单,在最后片元着色器输出颜色之前将发光颜色添加到输出颜色上就可以。
环境光计算
环境光一般被储存为一个全局变量,场景中的所有物体都使用这个环境光(近似认为环境光是一个常数),也是直接调用。
一般我们通过unity的内置变量UNITY_LIGHTMODEL_AMBIENT就能得到环境光的颜色和强度信息。
漫反射计算
漫反射光照满足兰伯特定律(Lambert’s law),即反射光线的强度和表面法线和光源方向夹角的余弦值成正比。
因此漫反射计算如下:
高光反射计算(重头戏)
高光反射是一种经典的经验模型,计算高光反射需要知道的信息比较多,
如表面法线,视角方向,光源方向,反射方向等。
以上信息我们一般只需要知道三个,因为反射方向可以通过这三个计算。
冯模型(phong)
我们来一一讲解其中系数的意义。
其中,mgloss是材质的光泽度,它是最后一个公式中最后一个项的指数,用于控制该高光区域亮点的大小。mgloss越大亮点就越小。
图中所示p就是我们的mgloss
mspecular是材质的高光反射颜色,用于控制高光反射的强度和颜色。
Clight是光源的颜色和强度。
最后我们会需要一个max函数来确保v和r(反射方向和视线方向)的余弦不为负值。
布林-冯模型(Blinn-Phong)
与上述的冯模型相比,布林冯模型提出了简单的修改方式来得到近似的效果,与此同时拥有更快的计算速度。
具体方法是引入一个新的矢量h(半程向量),通过对v和l(视线方向和入射方向)取平均再归一化得到它。
此时布林-冯模型的光照模型如下:
我们通过使用n和h之间的夹角进行运算来取代冯模型中的v和r的夹角,这样就能避免计算r来加快运算速度。
若摄像机和光源距离模型足够远时,布林冯模型会快于冯模型。
这两种光照模型都是经验模型,不该认为布林冯模型是对冯模型的”正确近似“。
逐顶点光照和逐像素光照(着色频率)
我们有两种选择,在顶点着色器中计算或是在片元着色器中计算。
逐顶点运算也称为高洛德着色(Gouraud shading),在每个顶点上计算光照,然后通过在渲染图元内进行线性插值。最后输出成像的颜色。
由于顶点数目往往远小于像素数目,因此逐顶点光照的计算量往往小于逐像素光照。
但逐顶点光照依赖线性插值,因此光照模型中有非线性运算时(例如计算高光反射)
逐顶点光照会有严重且明显的棱角现象。
逐像素运算也称冯着色(Phong shading),冯插值,法线插值着色技术。
我们以每个像素为基础得到它的法线,再对法线进行插值。
ps:注意与前文的冯模型区分开来。冯着色和冯模型都是同一个人发明的,但一个是光照模型,一个是着色频率。
因此哪一种着色频率更好呢?并无绝对。
几何足够复杂时,使用相对简单的着色模型也能得到不错的结果。
例如我们发现第三种几何形体已经很复杂了,但使用三种方法都相差无几,但使用冯着色模型会造成更大开销。
在Unityshader中实现各种光照
漫反射光照模型
我们在上文给出了基本光照模型中漫反射部分的计算公式:
我们需要知道四个参数,clight(入射光的颜色和强度),mdiffuse(材质的反射系数),n(表面法线),l(光源方向)。
Cg提供了saturate(x)函数来将x截取在[0,1]之内来完成max的操作。
漫反射逐顶点光照
为了得到并能控制材质的漫反射颜色,我们构造Properties语义块声明一个color。
properties
{
_Diffuse("Diffuse", Color) = (1, 1, 1, 1)
}
随后我们在pass的第一行指明该pass的光照模式为前向渲染。
SubShader{
Pass{
Tags{ "LightMode" = "ForwardBase"}//前向渲染
随后CGPROGRAM和ENDCG来包围Cg代码片,再包含内置文件lighting.cging,别忘了定义properties中属性相匹配的变量。
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Diffuse;
...
...
ENDCG
然后定义顶点着色器的输入和输出结构(顶点着色器的输出也是片元着色器的输入)
struct a2v
{
float4 vertex:POSITION;//顶点
float3 normal:NORMAL;//法线
};
struct v2f
{
float4 pos : SV_POSITION;//剪裁空间的顶点
fixed3 color : COLOR;//颜色
};
接下来就是最为关键的顶点着色器,漫反射部分都在顶点着色器中完成。
还记得我们的公式吧:
v2f vert(a2v v)
{
v2f o;
//将顶点位置从模型空间转换到剪裁空间
o.pos = UnityObjectToClipPos(v.vertex);
//通过内置变量获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
//获取法线方向并归一化(世界坐标)
fixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
//获取入射光并归一化(在这里是平行光)
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
//漫反射计算(入射光线的信息和漫反射系数都来自内置变量)
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLight));
//输出之前把环境光也加上。
o.color = ambient + diffuse;
return o;
}
我们在计算点积时,务必将两个向量置于同一个坐标系下,否则就没有意义了。这里我们选择的是世界空间。
因为a2v得到的顶点法线是模型空间下的,因此我们使用一个矩阵将法线转换到了世界空间。
光照方向则是内置变量直接做了归一化。
由于所有的计算在顶点着色器中完成了,因此片元着色器直接输出顶点颜色即可。
fixed4 frag(v2f i) :SV_Target
{
return fixed4(i.color,1.0);
}
最后记得将UnityShader的回调shader设为内置的diffuse
Fallback "Diffuse"
至此,我们详细的解释了逐顶点的漫反射光照实现。
对于一些细分程度高的模型,逐顶点光照也能得到不错的结果。
细分程度较低的话就有可能出现锯齿
漫反射逐像素光照
逐像素光照就是将计算部分从顶点着色器迁移到了片元着色器进行运算而已。
所使用的光照模型是一样的。代码和逐顶点光照较为相似。
修改顶点着色器的输出结构v2f(因为我们不在顶点着色器中计算光照模型了,因此只需要传递顶点就行)
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
};
顶点着色器只需要将世界空间下的法线传递给片元着色器即可
v2f vert(a2v v)
{
v2f o;
//转化顶点坐标
o.pos = UnityObjectToClipPos(v.vertex);
//法线方向(世界坐标)
o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
return o;
}
在片元着色器中计算光照模型
fixed4 frag(v2f i) :SV_Target
{
//环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
//法线方向(世界坐标)
fixed3 worldNormal = normalize(i.worldNormal);
//光照方向(世界坐标)
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
//漫反射计算(入射光线的信息和漫反射系数都来自内置变量)
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLight));
//环境光 +漫反射
fixed3 color = ambient + diffuse;
return fixed4(color,1.0);
}
上述计算过程与逐顶点光照完全相同。
逐像素光照可以得到更加平滑的光照效果,代价是更大的开销。
我们会发现不管是逐顶点还是逐像素光照,在光线到达不了的区域,模型的外观是全黑的,丢失了模型的细节表现。
我们可以添加环境光来得到非全黑效果,但这是治标不治本,因为这样光面的明暗是一样的(不该这样对吧)
为此,半兰伯特光照模型(Half Lambert)被提出。
半兰伯特光照模型
前文中我们使用的漫反射光照模型被称为兰伯特光照模型,因为他符合兰伯特定律。
即在某点漫反射的光强和该反射点的法向量和入射光的余弦成正比。
由于我们在原兰伯特光照模型上做了简单修改,因此该改进技术被称为半兰伯特光照模型。
不难看出,半兰伯特没有使用max操作来防止点积为负值
而是进行了一个α倍的缩放再加上一个β大小的偏移
绝大多数情况下两个系数的值都为0.5。
即公式为:
通过这样的方式就可以把点积结果从[-1,1]映射到[0,1]
需要注意的是,半兰伯特模型没有任何物理依据,仅仅是一个视觉加强技术(好像有小伙伴说被面试官问到这个)
我们在片元着色器中使用半兰伯特公式来取代原本的漫反射光照模型(兰伯特光照模型)
fixed4 frag(v2f i) :SV_Target
{
//环境光
...
//法线方向(世界坐标)
...
//光照方向(世界坐标)
...
//漫反射计算
fixed halfLambert=dot(worldNormal,worldLight)*0.5+0.5;
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * halfLambert;
//环境光 +漫反射
fixed3 color = ambient + diffuse;
return fixed4(color,1.0);
}
从左到右依次为半兰伯特模型,逐顶点光照,逐像素光照
高光反射光照模型
在上文中我们同样给出了高光反射的计算公式
我们需要知道三个参数:clight(入射光的颜色和强度),mspecular(材质的反射系数),v(视角方向)
还有一个参数r(反射方向)可以由表面法线n和光源方向l计算而得。
Cg提供了计算反射方向的函数reflect(i,n)来返回反射方向。
注意这里的i是光源方向l的取反,是由光源指向交点处的矢量。
高光反射逐顶点光照
我们这次在Properties语义中构造三个属性以便于更方便控制高光反射属性
渲染模式设为前向渲染,包含文件Lighting.cginc,设置与属性相匹配的变量
Properties
{
_Diffuse("Diffuse", Color) = (1,1,1,1)
_Specular("Specular",Color)=(1,1,1,1)
_Gloss("Gloss",Range(8.0,256))=20
}
SubShader{
Pass
{
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Diffuse;//颜色属性在0~1因此使用fixed的精度来储存
fixed4 _Specular;//颜色属性在0~1因此使用fixed的精度来储存
float _Gloss;//Gloss用来表示一个指数,范围较大,使用float来储存
定义顶点着色器的输入和输出结构体(和漫反射是一样的)
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
fixed3 color: COLOR;
};
在顶点着色器中包含高光反射的光照模型
这里是公式君:
v2f vert (a2v v)//高光反射逐顶点光照
{
v2f o;
//顶点变换
o.pos=UnityWorldToClipPos(v.vertex);
//获取环境光
fixed3 ambient=UNITY_LIGHTMODEL_AMBIENT.xyz;
//获取法线(世界坐标)
fixed3 worldNormal=normalize(mul(v.normal,(float3x3)unity_WorldToObject));
//获取入射光并归一化(在这里是平行光)
fixed3 worldLightDir=normalize(_WorldSpaceLightPos0.xyz);
//计算漫反射部分
fixed3 diffuse=_LightColor0.rgb*_Diffuse.rgb*saturate(dot(worldNormal,worldLightDir));
//计算反射光
fixed3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));
//获取视线方向
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - mul(unity_ObjectToWorld, v.vertex).xyz);
//计算高光反射
fixed3 specular=_LightColor0.rgb*_Specular.rgb*pow(saturate(dot(reflectDir,viewDir)),_Gloss);
//把环境光和漫反射加上
o.color=ambient+diffuse+specular;
return o;
}
漫反射部分的计算与前文完全一致。随后我们使用reflect函数计算反射光的方向。
注意这里对worldLightDir取反后再传入reflect函数。
我们通过_WorldSpaceCameraPos(内置变量)获取世界空间中的摄像机位置,
随后将世界坐标的顶点(从模型空间变换而来)和 _WorldSpaceCameraPos相减获得我们的视线矢量。
至此我们所需的四个参数(clight(入射光的颜色和强度),mspecular(材质的反射系数),v(视角方向)r(反射方向))已经全部获取
代入公式计算即可得到高光反射的光照部分。
片元着色器也同上直接返回顶点颜色,最后将回调shader设为内置的Specular
fixed4 frag (v2f i) : SV_Target
{
return fixed4(i.color,1.0);
}
ENDCG
}
}
Fallback "Specular"
需要注意的是使用逐顶点计算得到的高光效果有比较大的问题
这是因为高光反射的计算是非线性的,而逐顶点计算的过程依赖线性插值进行。
高光反射逐像素光照
修改结构体v2f
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal:TEXCOORD0;
float3 worldPos: TEXCOORD1;
};
顶点着色器只需要计算世界空间下的法线方向和顶点坐标,并传递给片元着色器
v2f vert (a2v v)//高光反射逐顶点光照
{
v2f o;
//顶点变换到剪裁空间
o.pos = UnityObjectToClipPos(v.vertex);
//获取法线(世界坐标)
o.worldNormal=mul(v.normal,(float3x3)unity_WorldToObject);
//获取顶点的世界坐标
o.worldPos=mul(unity_ObjectToWorld,v.vertex).xyz;
return o;
}
随后在片元着色器计算关键的光照模型
fixed4 frag (v2f i) : SV_Target
{
//获取环境光
fixed3 ambient=UNITY_LIGHTMODEL_AMBIENT.xyz;
//获取法线(世界坐标)
fixed3 worldNormal=normalize(i.worldNormal);
//获取平行光(归一化)
fixed3 worldLightDir=normalize(_WorldSpaceLightPos0.xyz);
//计算漫反射部分
fixed3 diffuse=_LightColor0.rgb*_Diffuse.rgb*saturate(dot(worldNormal,worldLightDir));
//计算反射光
fixed3 reflectDir=normalize(reflect(-worldLightDir,worldNormal));
//获取视线
fixed3 viewDir=normalize(_WorldSpaceCameraPos.xyz-i.worldPos.xyz);
//计算高光反射
fixed3 specular=_LightColor0.rgb*_Specular.rgb*pow(saturate(dot(reflectDir,viewDir)),_Gloss);
return fixed4(ambient+diffuse+specular,1.0);
}
计算过程与逐顶点完全相同。
按照逐像素方式可以实现更平滑的高光效果,至此我们实现了一个完整的冯光照模型。
布林冯光照模型
还记得布林冯公式吗?
我们直接修改逐片元光照的片元着色器:
fixed4 frag (v2f i) : SV_Target
{
...
//计算半程向量h
fixed3 HalfDir=normalize(viewDir+worldLightDir);
//计算高光反射
fixed3 specular=_LightColor0.rgb*_Specular.rgb*pow(saturate(dot(worldNormal,HalfDir)),_Gloss);
return fixed4(ambient+diffuse+specular,1.0);
}
从左到右依次是布林冯模型,逐顶点光照,逐像素光照
可以看出布林冯的高光部分更大更亮,实际渲染中我们大部分会选择布林冯模型。
但要记住,冯模型和布林冯模型都是经验模型,都不存在“正确”的概念。
unity的内置函数
上述shader的编写中,我们都是手动计算得到各个矢量变量,既不方便也不一定准确
例如_WorldSpaceLightPos0.xyz只适用平行光,若不是平行光则会得到错误结果。
但起码我们弄明白了其中的原理。
unity提供了内置函数来帮助我们计算这一系列的信息。
使用他们可以使我们减少编写shader时的痛苦。
例如
o.worldNormal=mul(v.normal,(float3x3)unity_WorldToObject);//手动计算得到世界坐标系下的法线
o.worldNormal=UnityObjectToWorldNormal(v.normal);//使用内置函数
需要注意的是,使用内置函数的结果是没有归一化的,因此请记得对结果进行normalize
终于写完了,从9点开始写现在已经一点了5555,2021-9-22 12:52
不过古人所言温故而知新是真的没有错,产出内容以后,对这章的内容的理解更加深刻了。